| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053 |
- 'use client';
- import { useState, useEffect, useCallback, useRef } from 'react';
- import { useParams, useRouter } from 'next/navigation';
- import { useAuth } from '@/lib/auth-context';
- import { projectsApi, assetsApi, invitationsApi, Project, Asset, Invitation, TranscodeStatus } from '@/lib/api';
- import { useDropzone } from 'react-dropzone';
- import { TranscodeTasksPanel } from '@/components/transcode/TranscodeTasksPanel';
- async function safeCopy(text: string): Promise<void> {
- if (typeof window === 'undefined') return;
- if (navigator.clipboard?.writeText) {
- try { await navigator.clipboard.writeText(text); } catch { /* ignore */ }
- } else {
- // Fallback: create a temp input so we can use execCommand on insecure contexts
- const el = document.createElement('textarea');
- el.value = text;
- el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
- document.body.appendChild(el);
- el.focus(); el.select();
- try { document.execCommand('copy'); } catch { /* ignore */ }
- document.body.removeChild(el);
- }
- }
- const ROLE_COLORS: Record<string, string> = {
- ADMIN: 'badge-danger',
- EDITOR: 'badge-brand',
- REVIEWER:'badge-muted',
- VIEWER: 'badge-subtle',
- };
- const ROLE_LABELS: Record<string, string> = {
- ADMIN: 'Admin',
- EDITOR: 'Editor',
- REVIEWER:'Reviewer',
- VIEWER: 'Viewer',
- };
- export default function ProjectDetailPage() {
- const params = useParams();
- const projectId = params.projectId as string;
- const { user, token } = useAuth();
- const router = useRouter();
- const [project, setProject] = useState<Project | null>(null);
- const [members, setMembers] = useState<any[]>([]);
- const [pendingInvites, setPendingInvites] = useState<Invitation[]>([]);
- const [assets, setAssets] = useState<Asset[]>([]);
- const [loading, setLoading] = useState(true);
- const [uploading, setUploading] = useState(false);
- const [activeTab, setActiveTab] = useState<'videos' | 'members' | 'transcode'>('videos');
- // Invite form state (single shared form)
- const [inviteEmail, setInviteEmail] = useState('');
- const [inviteRole, setInviteRole] = useState('REVIEWER');
- const [inviting, setInviting] = useState(false);
- const [inviteError, setInviteError] = useState('');
- const [inviteSuccess, setInviteSuccess] = useState('');
- const [createdLink, setCreatedLink] = useState('');
- // Edit member role
- const [editingRoleId, setEditingRoleId] = useState<string | null>(null);
- const [editingRole, setEditingRole] = useState('');
- const [updatingRole, setUpdatingRole] = useState(false);
- // Remove member
- const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null);
- const [removing, setRemoving] = useState(false);
- // Revoke invite
- const [revokingId, setRevokingId] = useState<string | null>(null);
- // Copy link
- const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
- const [inviteUrlMap, setInviteUrlMap] = useState<Record<string, string>>({});
- const canManage = members.some(m =>
- m.user.id === user?.id && ['ADMIN', 'EDITOR'].includes(m.role)
- );
- const isAdmin = members.some(m =>
- m.user.id === user?.id && m.role === 'ADMIN'
- );
- const loadAll = useCallback(async () => {
- if (!token) return;
- try {
- const [{ project: p }, { assets: a }] = await Promise.all([
- projectsApi.get(token, projectId),
- assetsApi.list(token, projectId),
- ]);
- setProject(p);
- setMembers(p.members ?? []);
- setAssets(a);
- if (canManage) {
- const { invitations } = await invitationsApi.list(token, projectId);
- setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
- }
- } catch {
- router.push('/projects');
- } finally {
- setLoading(false);
- }
- }, [token, projectId, router, canManage]);
- useEffect(() => { loadAll(); }, [loadAll]);
- // ── Invite member ──────────────────────────────────────────────────────────
- const handleInvite = async (e: React.FormEvent) => {
- e.preventDefault();
- if (!token || !inviteEmail.trim()) return;
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail.trim())) {
- setInviteError('Invalid email address');
- return;
- }
- setInviting(true);
- setInviteError('');
- setInviteSuccess('');
- setCreatedLink('');
- try {
- const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
- const { invitations } = await invitationsApi.list(token, projectId);
- setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
- setInviteUrlMap(prev => ({ ...prev, [inviteUrl.split('/').pop()!]: inviteUrl }));
- setInviteEmail('');
- setInviteSuccess(`Invitation sent to ${inviteEmail.trim()}`);
- setTimeout(() => setInviteSuccess(''), 3000);
- } catch (err) {
- setInviteError(err instanceof Error ? err.message : 'Failed to send invitation');
- } finally {
- setInviting(false);
- }
- };
- // ── Create & copy link ─────────────────────────────────────────────────────
- const handleCreateLink = async () => {
- if (!token || !inviteEmail.trim()) return;
- setInviting(true);
- setInviteError('');
- setInviteSuccess('');
- setCreatedLink('');
- try {
- const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
- const { invitations } = await invitationsApi.list(token, projectId);
- setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
- // API returns full URL now (e.g. http://localhost:3000/invite/xxx)
- await safeCopy(inviteUrl);
- setCreatedLink(inviteUrl);
- setInviteEmail('');
- } catch (err: any) {
- const msg = err instanceof Error ? err.message : String(err);
- if (msg.includes('already exists') || msg.includes('already a member') || msg.includes('409')) {
- setInviteError(`An invitation for "${inviteEmail.trim()}" is already pending or the user is already a member.`);
- } else {
- setInviteError(msg || 'Failed to create invitation link');
- }
- } finally {
- setInviting(false);
- }
- };
- // ── Change role ────────────────────────────────────────────────────────────
- const handleChangeRole = async (memberId: string) => {
- if (!token || !editingRole) return;
- setUpdatingRole(true);
- try {
- await projectsApi.updateMember(token, projectId, memberId, editingRole);
- setMembers(prev => prev.map(m => m.id === memberId ? { ...m, role: editingRole } : m));
- setEditingRoleId(null);
- } catch (err) {
- alert(err instanceof Error ? err.message : 'Failed to update role');
- } finally {
- setUpdatingRole(false);
- }
- };
- // ── Remove member ─────────────────────────────────────────────────────────
- const handleRemoveMember = async () => {
- if (!token || !confirmRemove) return;
- setRemoving(true);
- try {
- await projectsApi.removeMember(token, projectId, confirmRemove.id);
- setMembers(prev => prev.filter(m => m.id !== confirmRemove!.id));
- setConfirmRemove(null);
- } catch (err) {
- alert(err instanceof Error ? err.message : 'Failed to remove member');
- } finally {
- setRemoving(false);
- }
- };
- // ── Revoke invite ──────────────────────────────────────────────────────────
- const handleRevoke = async (invitationId: string) => {
- if (!token) return;
- setRevokingId(invitationId);
- try {
- await invitationsApi.revoke(token, invitationId);
- setPendingInvites(prev => prev.filter(i => i.id !== invitationId));
- } catch (err) {
- alert(err instanceof Error ? err.message : 'Failed to revoke invitation');
- } finally {
- setRevokingId(null);
- }
- };
- // ── Copy invite link ──────────────────────────────────────────────────────
- const handleCopyLink = async (invite: Invitation) => {
- const base = window.location.origin;
- const url = inviteUrlMap[invite.token] ?? `${base}/invite/${invite.token}`;
- await safeCopy(url);
- setCopiedInviteId(invite.id);
- setTimeout(() => setCopiedInviteId(null), 2000);
- };
- const handleDrop = async (acceptedFiles: File[]) => {
- if (!token || acceptedFiles.length === 0) return;
- setUploading(true);
- for (const file of acceptedFiles) {
- const formData = new FormData();
- formData.append('video', file);
- formData.append('projectId', projectId);
- formData.append('title', file.name.replace(/\.[^.]+$/, ''));
- try {
- const result = await assetsApi.upload(token, formData) as { asset: Asset };
- setAssets(prev => [result.asset, ...prev]);
- } catch (err) {
- console.error('Upload failed:', err);
- alert(`Upload failed: ${file.name}`);
- }
- }
- setUploading(false);
- };
- const { getRootProps, getInputProps, isDragActive } = useDropzone({
- onDrop: handleDrop,
- accept: { 'video/*': ['.mp4', '.mov', '.webm', '.avi', '.mpeg'] },
- multiple: true,
- disabled: uploading,
- });
- const statusColors: Record<string, string> = {
- PENDING_REVIEW: 'status-pending',
- CHANGES_REQUESTED: 'status-changes',
- APPROVED: 'status-approved',
- REJECTED: 'status-rejected',
- };
- const statusLabels: Record<string, string> = {
- PENDING_REVIEW: 'Pending',
- CHANGES_REQUESTED: 'Changes',
- APPROVED: 'Approved',
- REJECTED: 'Rejected',
- };
- // ── Transcode status helpers ────────────────────────────────────────────────
- const transcodeColors: Record<TranscodeStatus, { text: string; dot: string; bg: string }> = {
- PENDING: { text: '#94A3B8', dot: 'bg-slate-400', bg: 'rgba(148,163,184,0.10)' },
- UPLOADING: { text: '#60A5FA', dot: 'bg-blue-400', bg: 'rgba(96,165,250,0.10)' },
- PROCESSING: { text: '#A78BFA', dot: 'bg-violet-400', bg: 'rgba(167,139,250,0.10)' },
- COMPLETED: { text: '#34D399', dot: 'bg-emerald-400', bg: 'rgba(52,211,153,0.10)' },
- FAILED: { text: '#F87171', dot: 'bg-red-400', bg: 'rgba(248,113,113,0.10)' },
- UNSUPPORTED_CODEC: { text: '#FBBF24', dot: 'bg-amber-400', bg: 'rgba(251,191,36,0.10)' },
- };
- const transcodeLabels: Record<TranscodeStatus, string> = {
- PENDING: 'Queued',
- UPLOADING: 'Uploading',
- PROCESSING: 'Processing',
- COMPLETED: 'Ready',
- FAILED: 'Failed',
- UNSUPPORTED_CODEC: 'Unsupported codec',
- };
- // Poll for assets that are still processing
- const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
- // ── Delete asset ─────────────────────────────────────────────────────────
- const [confirmDelete, setConfirmDelete] = useState<{ id: string; title: string } | null>(null);
- const [deletingId, setDeletingId] = useState<string | null>(null);
- const handleDeleteAsset = (id: string, title: string) => {
- setConfirmDelete({ id, title });
- };
- const confirmDeleteAsset = async () => {
- if (!token || !confirmDelete) return;
- setDeletingId(confirmDelete.id);
- try {
- await assetsApi.delete(token, confirmDelete.id);
- setAssets(prev => prev.filter(a => a.id !== confirmDelete.id));
- setConfirmDelete(null);
- } catch (err) {
- alert(err instanceof Error ? err.message : 'Failed to delete video');
- } finally {
- setDeletingId(null);
- }
- };
- useEffect(() => {
- const processingAssets = assets.filter(a =>
- ['UPLOADING', 'PROCESSING', 'PENDING'].includes(a.transcodeStatus)
- );
- if (processingAssets.length === 0) {
- if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; }
- return;
- }
- if (pollingRef.current) return; // already polling
- pollingRef.current = setInterval(async () => {
- if (!token) return;
- try {
- const { assets: updated } = await assetsApi.list(token, projectId);
- setAssets(updated);
- } catch {}
- }, 3000);
- return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
- }, [token, projectId, assets]);
- if (loading) {
- return (
- <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
- <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
- <div className="w-5 h-5 rounded-full animate-spin"
- style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
- <span className="text-sm">Loading…</span>
- </div>
- </div>
- );
- }
- return (
- <div className="min-h-screen" style={{ background: 'var(--bg)' }}>
- {/* Header */}
- <header className="sticky top-0 z-10 px-8 py-4 flex items-center gap-5 shrink-0"
- style={{
- background: 'rgba(10,11,20,0.80)',
- backdropFilter: 'blur(12px)',
- borderBottom: '1px solid rgba(255,255,255,0.06)',
- }}>
- <button
- onClick={() => router.push('/projects')}
- className="flex items-center gap-1.5 text-sm transition-colors"
- style={{ color: 'var(--text-muted)' }}
- >
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
- </svg>
- Projects
- </button>
- <div className="w-px h-4" style={{ background: 'rgba(255,255,255,0.10)' }} />
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2">
- <h1 className="text-sm font-semibold truncate" style={{ color: 'var(--text)' }}>
- {project?.name}
- </h1>
- {canManage && (
- <span className="text-[10px] px-1.5 py-0.5 rounded-full shrink-0"
- style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}>
- {isAdmin ? 'Owner' : 'Editor'}
- </span>
- )}
- {!canManage && !isAdmin && (
- <span className="text-[10px] px-1.5 py-0.5 rounded-full shrink-0"
- style={{ background: 'rgba(255,255,255,0.04)', color: 'var(--text-subtle)' }}>
- {members.find(m => m.user.id === user?.id)?.role ?? 'Member'}
- </span>
- )}
- </div>
- {project?.description && (
- <p className="text-xs truncate mt-0.5" style={{ color: 'var(--text-muted)' }}>
- {project.description}
- </p>
- )}
- </div>
- {/* Tabs */}
- <div className="flex items-center gap-1 p-1 rounded-lg"
- style={{ background: 'rgba(255,255,255,0.04)' }}>
- {[['videos', 'Videos', assets.length], ['transcode', 'Transcode Tasks', assets.filter(a => a.transcodeStatus !== 'COMPLETED').length], ['members', 'Members', members.length]].map(([tab, label, count]) => (
- <button key={tab}
- onClick={() => setActiveTab(tab as any)}
- className="px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5"
- style={{
- background: activeTab === tab ? 'rgba(99,102,241,0.20)' : 'transparent',
- color: activeTab === tab ? '#A5B4FC' : 'var(--text-muted)',
- }}>
- {label}
- {tab !== 'members' && (count as number) > 0 && (
- <span className="text-[10px] px-1.5 py-0.5 rounded-full"
- style={{
- background: tab === 'transcode'
- ? 'rgba(167,139,250,0.25)'
- : 'rgba(255,255,255,0.06)',
- color: tab === 'transcode' ? '#A78BFA' : 'inherit',
- }}>
- {count}
- </span>
- )}
- {tab === 'members' && (
- <span className="ml-0.5 text-[10px] px-1.5 py-0.5 rounded-full"
- style={{ background: 'rgba(255,255,255,0.06)', color: 'inherit' }}>
- {members.length}
- </span>
- )}
- </button>
- ))}
- </div>
- <div className="text-xs px-2.5 py-1 rounded-full"
- style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
- {assets.length} video{assets.length !== 1 ? 's' : ''}
- </div>
- </header>
- <div className="px-8 py-6">
- {/* ── Videos Tab ───────────────────────────────────────────────────── */}
- {activeTab === 'videos' && (
- <>
- {/* Upload zone — only shown to EDITOR and ADMIN */}
- {canManage ? (
- <div
- {...getRootProps()}
- className="mb-8 rounded-2xl p-10 text-center cursor-pointer transition-all animate-fade-in"
- style={{
- background: isDragActive ? 'rgba(99,102,241,0.08)' : 'rgba(255,255,255,0.02)',
- border: isDragActive
- ? '1px solid rgba(99,102,241,0.40)'
- : '1px dashed rgba(255,255,255,0.10)',
- borderRadius: '16px',
- }}
- >
- <input {...getInputProps()} />
- {uploading ? (
- <div className="space-y-3">
- <div className="w-9 h-9 rounded-full mx-auto animate-spin"
- style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
- <p className="text-sm" style={{ color: 'var(--text-muted)' }}>Uploading…</p>
- </div>
- ) : (
- <>
- <div className="w-12 h-12 rounded-2xl mx-auto mb-4 flex items-center justify-center"
- style={{ background: 'rgba(99,102,241,0.10)', border: '1px solid rgba(99,102,241,0.15)' }}>
- <svg className="w-6 h-6" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
- </svg>
- </div>
- <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>
- {isDragActive ? 'Drop videos here' : 'Drag & drop videos, or click to browse'}
- </p>
- <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
- MP4, MOV, WebM — up to 500MB each
- </p>
- </>
- )}
- </div>
- ) : (
- <div className="mb-8 rounded-2xl p-6 text-center animate-fade-in"
- style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '16px' }}>
- <div className="w-10 h-10 rounded-2xl mx-auto mb-3 flex items-center justify-center"
- style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}>
- <svg className="w-5 h-5" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
- </svg>
- </div>
- <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
- Your role ({members.find(m => m.user.id === user?.id)?.role ?? 'Member'}) does not allow uploading videos.
- </p>
- </div>
- )}
- {/* Asset grid */}
- {assets.length === 0 ? (
- <div className="text-center py-20 rounded-2xl animate-fade-in"
- style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)' }}>
- <div className="w-14 h-14 rounded-2xl mx-auto mb-4 flex items-center justify-center"
- style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
- <svg className="w-7 h-7" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
- </svg>
- </div>
- <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>No videos yet</p>
- <p className="text-xs" style={{ color: 'var(--text-muted)' }}>Upload your first video using the dropzone above</p>
- </div>
- ) : (
- <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
- {assets.map((asset, i) => (
- <div key={asset.id}
- className="card overflow-hidden group"
- style={{ animation: `slideUp 0.25s ease-out ${i * 40}ms both` }}>
- {/* Thumbnail */}
- <div className="relative aspect-video" style={{ background: '#080810' }} onClick={() => router.push(`/review/${asset.id}`)}>
- {/* Play overlay — only show when ready */}
- {asset.transcodeStatus === 'COMPLETED' && (
- <>
- {asset.thumbnail ? (
- <img
- src={`/uploads/${asset.thumbnail}`}
- alt={asset.title}
- className="w-full h-full object-cover"
- style={{ opacity: 0.85 }}
- />
- ) : (
- <div className="w-full h-full flex items-center justify-center">
- <svg className="w-10 h-10" style={{ color: 'rgba(255,255,255,0.15)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
- <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
- </svg>
- </div>
- )}
- <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200"
- style={{ background: 'rgba(0,0,0,0.35)' }}>
- <div className="w-12 h-12 rounded-full flex items-center justify-center"
- style={{ background: 'rgba(99,102,241,0.80)', boxShadow: '0 0 24px rgba(99,102,241,0.4)' }}>
- <svg className="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
- <path d="M8 5v14l11-7z" />
- </svg>
- </div>
- </div>
- </>
- )}
- {/* Not ready — show transcode status overlay */}
- {asset.transcodeStatus !== 'COMPLETED' && (
- <div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
- {/* Animated spinner */}
- {['UPLOADING', 'PROCESSING', 'PENDING'].includes(asset.transcodeStatus) && (
- <div className="w-10 h-10 rounded-full animate-spin"
- style={{ borderColor: transcodeColors[asset.transcodeStatus]?.dot.replace('bg-','#').replace('-400','' ) || '#6366F1', borderTopColor: 'transparent', borderWidth: '3px' }} />
- )}
- {/* Error icon */}
- {asset.transcodeStatus === 'FAILED' && (
- <div className="w-10 h-10 rounded-full flex items-center justify-center"
- style={{ background: 'rgba(248,113,113,0.15)' }}>
- <svg className="w-5 h-5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
- </svg>
- </div>
- )}
- {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
- <div className="w-10 h-10 rounded-full flex items-center justify-center"
- style={{ background: 'rgba(251,191,36,0.15)' }}>
- <svg className="w-5 h-5" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
- </svg>
- </div>
- )}
- {/* Status label */}
- <span className="text-xs font-medium px-2.5 py-1 rounded-full"
- style={{ background: transcodeColors[asset.transcodeStatus]?.bg, color: transcodeColors[asset.transcodeStatus]?.text }}>
- {transcodeLabels[asset.transcodeStatus]}
- </span>
- </div>
- )}
- {/* Progress bar */}
- {['UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus) && (
- <div className="absolute bottom-0 left-0 right-0 h-1 overflow-hidden"
- style={{ background: 'rgba(0,0,0,0.3)' }}>
- <div
- className="h-full transition-all duration-500"
- style={{
- width: `${asset.transcodeProgress}%`,
- background: 'linear-gradient(90deg, #818CF8, #A78BFA)',
- }}
- />
- </div>
- )}
- {/* Duration badge */}
- {asset.duration && asset.transcodeStatus === 'COMPLETED' && (
- <span className="absolute bottom-2 right-2 text-xs px-1.5 py-0.5 rounded-md font-mono"
- style={{ background: 'rgba(0,0,0,0.70)', color: '#E2E8F0' }}>
- {(() => { const m = Math.floor(asset.duration! / 60); const s = Math.floor(asset.duration! % 60); return `${m}:${s.toString().padStart(2,'0')}`; })()}
- </span>
- )}
- {/* Codec badge */}
- {asset.codec && asset.transcodeStatus !== 'COMPLETED' && (
- <span className="absolute top-2 left-2 text-[10px] px-1.5 py-0.5 rounded font-mono"
- style={{ background: 'rgba(0,0,0,0.6)', color: '#94A3B8' }}>
- {asset.codec}
- </span>
- )}
- </div>
- {/* Info */}
- <div className="p-4">
- <div className="flex items-start justify-between gap-2 mb-2">
- <h3 className="text-sm font-medium truncate flex-1 transition-colors"
- style={{ color: 'var(--text)' }}>
- {asset.title}
- </h3>
- <span className={`badge shrink-0 ${statusColors[asset.status]}`}>
- {statusLabels[asset.status]}
- </span>
- </div>
- {/* Transcode status row */}
- {asset.transcodeStatus !== 'COMPLETED' && (
- <div className="mb-2 flex items-center gap-1.5">
- <div
- className={`w-1.5 h-1.5 rounded-full shrink-0 ${['UPLOADING','PROCESSING'].includes(asset.transcodeStatus) ? 'animate-pulse' : ''} ${transcodeColors[asset.transcodeStatus]?.dot}`}
- />
- <span className="text-[11px] truncate" style={{ color: transcodeColors[asset.transcodeStatus]?.text }}>
- {transcodeLabels[asset.transcodeStatus]}
- {['UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus) && asset.transcodeProgress > 0
- ? ` — ${asset.transcodeProgress}%`
- : ''}
- </span>
- {asset.transcodeStatus === 'FAILED' && asset.transcodeError && (
- <span className="text-[10px] truncate" style={{ color: '#F87171' }}>
- : {asset.transcodeError}
- </span>
- )}
- {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
- <span className="text-[10px] truncate" style={{ color: '#FB923C' }}>
- — will re-encode to H.264
- </span>
- )}
- </div>
- )}
- <div className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
- <span>{(asset as any)._count?.comments ?? 0} comment{((asset as any)._count?.comments ?? 0) !== 1 ? 's' : ''}</span>
- <span className="w-1 h-1 rounded-full" style={{ background: 'rgba(255,255,255,0.12)' }} />
- <span>{new Date(asset.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>
- <div className="flex-1" />
- {canManage && (
- <button
- onClick={(e) => { e.stopPropagation(); handleDeleteAsset(asset.id, asset.title); }}
- className="p-1 rounded transition-colors hover:bg-red-500/20 flex-shrink-0"
- title="Delete video"
- >
- <svg className="w-3.5 h-3.5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
- </svg>
- </button>
- )}
- </div>
- </div>
- </div>
- ))}
- </div>
- )}
- </>
- )}
- {/* ── Transcode Tasks Tab ─────────────────────────────────────────── */}
- {activeTab === 'transcode' && (
- <div className="animate-fade-in">
- <TranscodeTasksPanel
- assets={assets}
- token={token}
- canManage={canManage}
- onDelete={handleDeleteAsset}
- onCancel={async (id) => {
- if (!token) return;
- try {
- await assetsApi.cancelTranscode(token, id);
- setAssets(prev => prev.map(a => a.id === id ? {
- ...a,
- transcodeStatus: 'PENDING',
- transcodeProgress: 0,
- transcodeError: null,
- hlsPath: null,
- } : a));
- } catch (err) {
- alert(err instanceof Error ? err.message : 'Failed to cancel transcode');
- }
- }}
- />
- </div>
- )}
- {/* ── Members Tab ─────────────────────────────────────────────────── */}
- {activeTab === 'members' && (
- <div className="max-w-3xl animate-fade-in">
- {/* Invite form — single form, shared email + role */}
- {canManage && (
- <div className="card p-5 mb-6">
- <h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>
- Invite someone
- </h2>
- <div className="space-y-3">
- <form
- onSubmit={e => { e.preventDefault(); handleInvite(e); }}
- className="flex items-end gap-3 flex-wrap"
- >
- <div className="flex-1 min-w-[180px]">
- <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>
- Email address
- </label>
- <input
- type="email"
- className="input"
- value={inviteEmail}
- onChange={e => setInviteEmail(e.target.value)}
- placeholder="colleague@company.com"
- />
- </div>
- <div className="w-36">
- <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Role</label>
- <select
- className="input"
- value={inviteRole}
- onChange={e => setInviteRole(e.target.value)}
- >
- {Object.entries(ROLE_LABELS).map(([value, label]) => (
- <option key={value} value={value}>{label}</option>
- ))}
- </select>
- </div>
- {/* Both buttons share the same email + role from this single form */}
- <button
- type="button"
- disabled={inviting || !inviteEmail.trim()}
- onClick={handleCreateLink}
- className="btn btn-secondary btn-md"
- title="Create invite link and copy to clipboard"
- >
- {inviting ? 'Creating…' : (
- <span className="flex items-center gap-1.5">
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
- </svg>
- Copy Link
- </span>
- )}
- </button>
- <button
- type="submit"
- disabled={inviting || !inviteEmail.trim()}
- className="btn btn-primary btn-md"
- title="Send invite — link is included automatically"
- >
- {inviting ? 'Sending…' : 'Send Invite'}
- </button>
- </form>
- {/* Created link feedback */}
- {createdLink && (
- <div className="rounded-lg p-3 animate-scale-in"
- style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
- <div className="flex items-center gap-2 mb-1.5">
- <svg className="w-3.5 h-3.5 shrink-0" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
- </svg>
- <span className="text-xs font-medium" style={{ color: '#86EFAC' }}>Link copied!</span>
- </div>
- <p className="text-[10px] break-all" style={{ color: 'rgba(134,239,172,0.7)' }}>
- {createdLink}
- </p>
- </div>
- )}
- {inviteError && (
- <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>
- )}
- {inviteSuccess && (
- <p className="text-xs" style={{ color: '#86EFAC' }}>{inviteSuccess}</p>
- )}
- </div>
- </div>
- )}
- {/* Members list */}
- <div className="card overflow-hidden mb-6">
- <div className="px-5 py-4 border-b" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
- <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
- Members ({members.length})
- </h2>
- </div>
- {members.length === 0 ? (
- <div className="p-8 text-center">
- <p className="text-sm" style={{ color: 'var(--text-muted)' }}>No members yet</p>
- </div>
- ) : (
- <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
- {members.map(m => {
- const isMe = m.user.id === user?.id;
- const canEdit = isAdmin && !isMe;
- return (
- <div key={m.id}
- className="flex items-center gap-4 px-5 py-4 hover:bg-white/[0.02] transition-colors">
- {/* Avatar */}
- <div className="w-9 h-9 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
- style={{ background: 'rgba(99,102,241,0.15)', color: '#A5B4FC' }}>
- {m.user.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()}
- </div>
- {/* Info */}
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2">
- <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
- {m.user.name}
- {isMe && <span className="ml-1.5 text-[10px]" style={{ color: 'var(--text-subtle)' }}>(you)</span>}
- </span>
- </div>
- <p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{m.user.email}</p>
- </div>
- {/* Joined date */}
- <span className="text-xs hidden sm:block" style={{ color: 'var(--text-subtle)' }}>
- {new Date(m.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
- </span>
- {/* Role */}
- {editingRoleId === m.id ? (
- <div className="flex items-center gap-2 shrink-0">
- <select
- className="input text-xs py-1.5"
- value={editingRole}
- onChange={e => setEditingRole(e.target.value)}
- autoFocus
- >
- {Object.entries(ROLE_LABELS).map(([v, l]) => (
- <option key={v} value={v}>{l}</option>
- ))}
- </select>
- <button
- onClick={() => handleChangeRole(m.id)}
- disabled={updatingRole}
- className="btn btn-primary btn-sm px-2"
- title="Save"
- >
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
- </svg>
- </button>
- <button
- onClick={() => setEditingRoleId(null)}
- className="btn btn-secondary btn-sm px-2"
- title="Cancel"
- >
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
- </svg>
- </button>
- </div>
- ) : (
- <div className="flex items-center gap-2 shrink-0">
- <span className={`badge ${ROLE_COLORS[m.role] ?? 'badge-muted'}`}>
- {ROLE_LABELS[m.role] ?? m.role}
- </span>
- {canEdit && (
- <button
- onClick={() => { setEditingRoleId(m.id); setEditingRole(m.role); }}
- className="btn btn-secondary btn-sm"
- title="Change role"
- >
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
- </svg>
- </button>
- )}
- {isAdmin && !isMe && (
- <button
- onClick={() => setConfirmRemove({ id: m.user.id, name: m.user.name })}
- className="btn btn-danger btn-sm"
- title="Remove from project"
- >
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M22 12h-4l-3 9L9 3l-3 9H2" />
- </svg>
- </button>
- )}
- </div>
- )}
- </div>
- );
- })}
- </div>
- )}
- </div>
- {/* Pending invitations */}
- {canManage && (
- <div className="card overflow-hidden">
- <div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
- <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
- Pending invitations
- </h2>
- <span className="text-xs px-2 py-0.5 rounded-full"
- style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
- {pendingInvites.length}
- </span>
- </div>
- {pendingInvites.length === 0 ? (
- <div className="p-8 text-center">
- <p className="text-xs" style={{ color: 'var(--text-subtle)' }}>No pending invitations</p>
- </div>
- ) : (
- <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
- {pendingInvites.map(inv => (
- <div key={inv.id}
- className="flex items-center gap-4 px-5 py-4">
- {/* Icon */}
- <div className="w-9 h-9 rounded-full flex items-center justify-center shrink-0"
- style={{ background: 'rgba(99,102,241,0.08)' }}>
- <svg className="w-4 h-4" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
- </svg>
- </div>
- {/* Info */}
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2">
- <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{inv.email}</span>
- <span className={`badge ${ROLE_COLORS[inv.role] ?? 'badge-muted'}`}>
- {ROLE_LABELS[inv.role] ?? inv.role}
- </span>
- </div>
- <div className="flex items-center gap-3 mt-0.5 text-xs" style={{ color: 'var(--text-subtle)' }}>
- <span>Sent {new Date(inv.createdAt).toLocaleDateString()}</span>
- <span>·</span>
- <span>Expires {new Date(inv.expiresAt).toLocaleDateString()}</span>
- </div>
- </div>
- {/* Actions */}
- <div className="flex items-center gap-1.5 shrink-0">
- <button
- onClick={() => handleCopyLink(inv)}
- className="btn btn-secondary btn-sm"
- title="Copy invite link"
- >
- {copiedInviteId === inv.id ? (
- <svg className="w-3.5 h-3.5" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
- </svg>
- ) : (
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
- </svg>
- )}
- </button>
- <button
- onClick={() => handleRevoke(inv.id)}
- disabled={revokingId === inv.id}
- className="btn btn-danger btn-sm"
- title="Revoke invitation"
- >
- {revokingId === inv.id ? '…' : (
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
- </svg>
- )}
- </button>
- </div>
- </div>
- ))}
- </div>
- )}
- {pendingInvites.length > 0 && (
- <div className="px-5 py-3 border-t" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
- <p className="text-xs" style={{ color: 'var(--text-subtle)' }}>
- Invitation links expire after 7 days. Copy the link and send it manually, or ask the recipient to check their email.
- </p>
- </div>
- )}
- </div>
- )}
- </div>
- )}
- </div>
- {/* Delete asset confirm modal */}
- {confirmDelete && (
- <div className="fixed inset-0 z-50 flex items-center justify-center"
- style={{ background: 'rgba(0,0,0,0.7)' }}>
- <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
- <div className="flex items-center gap-3 mb-4">
- <div className="w-10 h-10 rounded-full flex items-center justify-center shrink-0"
- style={{ background: 'rgba(248,113,113,0.15)' }}>
- <svg className="w-5 h-5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
- </svg>
- </div>
- <div>
- <h3 className="text-base font-semibold" style={{ color: 'var(--text)' }}>Delete video?</h3>
- <p className="text-xs mt-0.5 truncate max-w-[220px]" style={{ color: 'var(--text-muted)' }}>
- "{confirmDelete.title}"
- </p>
- </div>
- </div>
- <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
- This will permanently delete the video file, thumbnail, and all HLS segments. This action cannot be undone.
- </p>
- <div className="flex gap-3 justify-end">
- <button
- onClick={() => setConfirmDelete(null)}
- disabled={!!deletingId}
- className="btn btn-secondary btn-md"
- >
- Cancel
- </button>
- <button
- onClick={confirmDeleteAsset}
- disabled={!!deletingId}
- className="btn btn-danger btn-md"
- >
- {deletingId === confirmDelete.id ? 'Deleting…' : 'Delete video'}
- </button>
- </div>
- </div>
- </div>
- )}
- {/* Remove member confirm modal */}
- {confirmRemove && (
- <div className="fixed inset-0 z-50 flex items-center justify-center"
- style={{ background: 'rgba(0,0,0,0.7)' }}>
- <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
- <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--text)' }}>
- Remove {confirmRemove.name}?
- </h3>
- <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
- They'll lose access to this project and all its videos. They can rejoin if invited again.
- </p>
- <div className="flex gap-3 justify-end">
- <button onClick={() => setConfirmRemove(null)} className="btn btn-secondary btn-md">
- Cancel
- </button>
- <button
- onClick={handleRemoveMember}
- disabled={removing}
- className="btn btn-danger btn-md"
- >
- {removing ? 'Removing…' : 'Remove'}
- </button>
- </div>
- </div>
- </div>
- )}
- </div>
- );
- }
|